RISC-V: A Baremetal Introduction using C++. Overview.
As described in Part 1, a simple C++ application to blink an LED, what does this look like with no operating system?
Blinky in C++
Here we have blinky on SiFive HiFive1 Rev B development board, built and loaded via Platform IO.
Let’s look at the program flow, and the C++ and RISC-V features used. All code here is C++. The drivers and startup routine not shown here are also C++.
(1) Instantiating the drivers for the timer and GPIO.
- RISC-V has a standard timer. However, there is no standard RISC-V GPIO. On this device, we use SiFive’s GPIO.
- The C++ drivers are using template parameters to configure the drivers for the hardware at compile time. Two methods are used here:
- For the timer, the template parameter is a trait, a struct that exists purely to isolate the implementation details. Both the address map and base clock frequency are parameterized as these are not standardized for RISC-V.
- For the GPIO we’re simply passing a value,
SIFIVE_GPIO0_0
, as the base address of the device as a template parameter. - By using C++ templates there is no need to store those parameters in the instance, so these instantiations consume no resources.
struct mtimer_address_spec {
static constexpr std::uintptr_t MTIMECMP_ADDR = 0x20004000;
static constexpr std::uintptr_t MTIME_ADDR = 0x2000BFF8;
};
struct mtimer_timer_config {
static constexpr unsigned int MTIME_FREQ_HZ=32768;
};
static constexpr uintptr_t SIFIVE_GPIO0_0 = 0x10012000;
...
driver::timer<mtimer_address_spec, mtimer_timer_config> mtimer;
driver::sifive_gpio0_0_dev<SIFIVE_GPIO0_0> gpio_dev;
(2) Initializing the timer.
- The timer is set to interrupt after a one-second timeout. C++’s
std::chrono
is used to convert from human-readable time into hardware clocks behind the scenes at compile time. - RISC-V defines a standard machine mode timer with a compare and time counter register.
mtimer.set_time_cmp(std::chrono::seconds{1});
(3) Enable the GPIO.
- This driver is not hiding the hardware details, it simply exposes the registers.
- The pin assignment details are hidden with a variable,
LED_MASK_WHITE
defined as a C++constexpr
. Aconstexpr
has zero runtime cost and allows us to avoid the issues of the C Pre-processor. Modern C++ has almost no need to use the C++ preprocessor.
static constexpr int LED_RED=22;
static constexpr int LED_GREEN=19;
static constexpr int LED_BLUE=21;
static constexpr unsigned int LED_MASK_WHITE=
bitmask(LED_RED)|bitmask(LED_GREEN)|bitmask(LED_BLUE);
...
gpio_dev.output_val &= ~(LED_MASK_WHITE);
gpio_dev.output_en |= (LED_MASK_WHITE);
(4) Declare an interrupt handler.
- RISC-V defines a standard system register for interrupt vector configuration.
- As this is modern C++ we can use a lambda function that avoids the need to share data between the interrupt and main function via globals.
- The lambda captures its context (that is the variables in the current scope) via references.
- The details of loading the lambda function are hidden in the
irq_handler
class’s templated constructor.
static const auto handler = [&] (void)
{
auto this_cause = riscv::csrs.mcause.read();
// ...more code...
}
irq::handler irq_handler(handler);
(5) Enable interrupts via system registers.
- The RISC-V defines system registers for the timer interrupt (
mie.mti
) and global interrupt (mstatus.mie
) enables. - RISC-V system registers are accessed via special instructions, and these C++ statements will compile to those instructions.
- We can use a C++ class
riscv::csr::all
instantiated asriscv::csrs
to provide an abstraction for the RISC-V system registers.
#include "riscv-csr.hpp"
...
riscv::csrs.mie.mti.set();
riscv::csrs.mstatus.mie.set();
(6) Enter a busy loop and stay there.
- There is no C++ here, just the wait for interrupt instruction. More program logic and abstraction could be added here, but blinky does not need it.
do {
__asm__ volatile ("wfi");
} while (true);
(7) Handle the timer interrupt.
- The interrupt handler reads the
mcause
system register to de-multiplex the cause of the interrupt. - The RISC-V machine timer is not auto-reloading, so the handler must reload it.
- While this example uses a single IRQ handler, RISC-V supports both multiplexed and vectored interrupts.
- The GPIO
output_val
bits are toggled to blink the LED. The register interface is simple, so no more abstraction is added.
auto this_cause = riscv::csrs.mcause.read();
if (this_cause &
riscv::csr::mcause_data::interrupt::BIT_MASK) {
this_cause &= 0xFF;
switch (this_cause) {
case riscv::interrupts::mti :
mtimer.set_time_cmp(std::chrono::seconds{1});
timestamp =
mtimer.get_time<
driver::timer<>::timer_ticks>().count();
gpio_dev.output_val ^= (LED_MASK_WHITE);
break;
}
}
The Complete Example
Putting together the above steps we can write the main()
function that will set up a one-second timer and blink the LED.
int main(void) {// Device drivers
driver::sifive_gpio0_0_dev<SIFIVE_GPIO0_0> gpio_dev;
driver::timer<> mtimer; // Device Setup // Save the timer value at this time.
auto timestamp =
mtimer.get_time<driver::timer<>::timer_ticks>().count();
// Setup timer for 1 second interval
mtimer.set_time_cmp(std::chrono::seconds{1}); // Enable GPIO
gpio_dev.output_val &= ~(LED_MASK_WHITE);
gpio_dev.output_en |= (LED_MASK_WHITE); // The periodic interrupt lambda function.
// The context (drivers etc) is captured via reference using [&]
static const auto handler = [&] (void)
{
// In RISC-V the mcause register stores the
// cause of any interrupt or exception.
auto this_cause = riscv::csrs.mcause.read();
// For simplicity non-vectored interrupt mode is used.
// The top bit of the mcause register indicates
// if this is an interrupt or exception.
if (this_cause &
riscv::csr::mcause_data::interrupt::BIT_MASK) {
this_cause &= 0xFF;
// De-multiplex the interrupt
// The cause register LSBs hold an integer value
// that represents the interrupt source
switch (this_cause) {
case riscv::interrupts::mti :
// A machine timer interrupt
// RISC-V machine mode timer interrupts
// are not repeating.
// Set the timer compare register to the
// current time + one second
mtimer.set_time_cmp(std::chrono::seconds{1});
// Save the timestamp as a raw counter in
// units of the hardware counter.
// While there is quite a bit of code here,
// it can be resolved at compile time to a
// simple MMIO register read.
timestamp =
mtimer.get_time<
driver::timer<>::timer_ticks>().count();
// Xor to invert. This can be compiled to a
// write to the toggle register via
// operator overloading.
gpio_dev.output_val ^= (LED_MASK_WHITE);
break;
}
}
}; // Install the above lambda function as the machine mode
// IRQ handler.
irq::handler irq_handler(handler); // Enable interrupts
riscv::csrs.mie.mti.set();
// Global interrupt enable
riscv::csrs.mstatus.mie.set(); // Busy loop
do {
__asm__ volatile ("wfi");
} while (true); return 0; // Never executed
}
Last Words
It’s compact C++ code but does not hide the operation of the hardware.
I hope this post has demonstrated the basics of a low-level program for the RISC-V and the capabilities of using modern C++ for efficient low-level programming.
Certainly, more abstraction could be used to hide details of the architecture and operation — but that would defeat some of the purpose of this example for learning RISC-V!
Feel free to try it out via the project on GitHub and read the next post to find out how to compile and load the project.
The next post describes the development environment for RISC-V C++.